#-----------------------------------------------------------------------------
# eveapi - EVE Online API access
#
# Copyright (c)2007 Jamie "Entity" van den Berge <entity@vapor.com>
# 
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE
#
#-----------------------------------------------------------------------------
# Version: 1.1.0 - 15 Januari 2009
# - Added Select() method to Rowset class. Using it avoids the creation of
#   temporary row instances, speeding up iteration considerably.
# - Added ParseXML() function, which can be passed arbitrary API XML file or
#   string objects.
# - Added support for proxy servers. A proxy can be specified globally or
#   per api connection instance. [suggestion by graalman]
# - Some minor refactoring.
# - Fixed deprecation warning when using Python 2.6.
#
# Version: 1.0.7 - 14 November 2008
# - Added workaround for rowsets that are missing the (required!) columns
#   attribute. If missing, it will use the columns found in the first row.
#   Note that this is will still break when expecting columns, if the rowset
#   is empty. [Flux/Entity]
#
# Version: 1.0.6 - 18 July 2008
# - Enabled expat text buffering to avoid content breaking up. [BigWhale]
#
# Version: 1.0.5 - 03 February 2008
# - Added workaround to make broken XML responses (like the "row:name" bug in
#   eve/CharacterID) work as intended.
# - Bogus datestamps before the epoch in XML responses are now set to 0 to
#   avoid breaking certain date/time functions. [Anathema Matou]
#
# Version: 1.0.4 - 23 December 2007
# - Changed _autocast() to use timegm() instead of mktime(). [Invisible Hand]
# - Fixed missing attributes of elements inside rows. [Elandra Tenari]
#
# Version: 1.0.3 - 13 December 2007
# - Fixed keyless columns bugging out the parser (in CorporationSheet for ex.)
#
# Version: 1.0.2 - 12 December 2007
# - Fixed parser not working with indented XML.
#
# Version: 1.0.1
# - Some micro optimizations
#
# Version: 1.0
# - Initial release
#
# Requirements:
#   Python 2.4+
#
#-----------------------------------------------------------------------------

import httplib
import urllib
import copy

from xml.parsers import expat
from time import strptime
from calendar import timegm

proxy = None

#-----------------------------------------------------------------------------

class Error(StandardError):
	def __init__(self, code, message):
		self.code = code
		self.args = (message.rstrip("."),)


def EVEAPIConnection(url="api.eve-online.com", cacheHandler=None, proxy=None):
	# Creates an API object through which you can call remote functions.
	#
	# The following optional arguments may be provided:
	#
	# url - root location of the EVEAPI server
	#
	# proxy - (host,port) specifying a proxy server through which to request
	#         the API pages. Specifying a proxy overrides default proxy.
	#
	# cacheHandler - an object which must support the following interface:
	#
	#      retrieve(host, path, params)
	#
	#          Called when eveapi wants to fetch a document.
	#          host is the address of the server, path is the full path to
	#          the requested document, and params is a dict containing the
	#          parameters passed to this api call (userID, apiKey etc).
	#          The method MUST return one of the following types:
	#
	#           None - if your cache did not contain this entry
	#           str/unicode - eveapi will parse this as XML
	#           Element - previously stored object as provided to store()
	#           file-like object - eveapi will read() XML from the stream.
	#
	#      store(host, path, params, doc, obj)
	#
	#          Called when eveapi wants you to cache this item.
	#          You can use obj to get the info about the object (cachedUntil
	#          and currentTime, etc) doc is the XML document the object
	#          was generated from. It's generally best to cache the XML, not
	#          the object, unless you pickle the object. Note that this method
	#          will only be called if you returned None in the retrieve() for
	#          this object.
	#

	if url.lower().startswith("http://"):
		url = url[7:]

	if "/" in url:
		url, path = url.split("/", 1)
	else:
		path = ""

	ctx = _RootContext(None, path, {}, {})
	ctx._handler = cacheHandler
	ctx._host = url
	ctx._proxy = proxy or globals()["proxy"]
	return ctx


def ParseXML(file_or_string):
	try:
		return _ParseXML(file_or_string, False, None)
	except TypeError:
		raise TypeError("XML data must be provided as string or file-like object")


def _ParseXML(response, fromContext, storeFunc):
	# pre/post-process XML or Element data

	if fromContext and isinstance(response, Element):
		obj = response
	elif type(response) in (str, unicode):
		obj = _Parser().Parse(response, False)
	elif hasattr(response, "read"):
		obj = _Parser().Parse(response, True)
	else:
		raise TypeError("retrieve method must return None, string, file-like object or an Element instance")

	error = getattr(obj, "error", False)
	if error:
		raise Error(error.code, error.data)

	result = getattr(obj, "result", False)
	if not result:
		raise RuntimeError("API object does not contain result")

	if fromContext and storeFunc:
		# call the cache handler to store this object
		storeFunc(obj)

	# make metadata available to caller somehow
	result._meta = obj

	return result


	


#-----------------------------------------------------------------------------
# API Classes
#-----------------------------------------------------------------------------

_listtypes = (list, tuple, dict)
_unspecified = []

class _Context(object):

	def __init__(self, root, path, parentDict, newKeywords=None):
		self._root = root or self
		self._path = path
		if newKeywords:
			if parentDict:
				self.parameters = parentDict.copy()
			else:
				self.parameters = {}
			self.parameters.update(newKeywords)
		else:
			self.parameters = parentDict or {}

	def context(self, *args, **kw):
		if kw or args:
			path = self._path
			if args:
				path += "/" + "/".join(args)
			return self.__class__(self._root, path, self.parameters, kw)
		else:
			return self

	def __getattr__(self, this):
		# perform arcane attribute majick trick
		return _Context(self._root, self._path + "/" + this, self.parameters)

	def __call__(self, **kw):
		if kw:
			# specified keywords override contextual ones
			for k, v in self.parameters.iteritems():
				if k not in kw:
					kw[k] = v
		else:
			# no keywords provided, just update with contextual ones.
			kw.update(self.parameters)

		# now let the root context handle it further
		return self._root(self._path, **kw)


class _AuthContext(_Context):

	def character(self, characterID):
		# returns a copy of this connection object but for every call made
		# through it, it will add the folder "/char" to the url, and the
		# characterID to the parameters passed.
		return _Context(self._root, self._path + "/char", self.parameters, {"characterID":characterID})

	def corporation(self, characterID):
		# same as character except for the folder "/corp"
		return _Context(self._root, self._path + "/corp", self.parameters, {"characterID":characterID})


class _RootContext(_Context):

	def auth(self, userID=None, apiKey=None):
		# returns a copy of this object but for every call made through it, the
		# userID and apiKey will be added to the API request.
		if userID and apiKey:
			return _AuthContext(self._root, self._path, self.parameters, {"userID":userID, "apiKey":apiKey})
		raise ValueError("Must specify userID and apiKey")

	def setcachehandler(self, handler):
		self._root._handler = handler

	def __call__(self, path, **kw):
		# convert list type arguments to something the API likes
		for k, v in kw.iteritems():
			if isinstance(v, _listtypes):
				kw[k] = ','.join(map(str, list(v)))

		cache = self._root._handler

		# now send the request
		path += ".xml.aspx"

		if cache:
			response = cache.retrieve(self._host, path, kw)
		else:
			response = None

		if response is None:
			if self._proxy is None:
				http = httplib.HTTPConnection(self._host)
				if kw:
					http.request("POST", path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
				else:
					http.request("GET", path)
			else:
				http = httplib.HTTPConnection(*self._proxy)
				if kw:
					http.request("POST", 'http://'+self._host+path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
				else:
					http.request("GET", 'http://'+self._host+path)

			response = http.getresponse()
			if response.status != 200:
				if response.status == httplib.NOT_FOUND:
					raise AttributeError("'%s' not available on API server (404 Not Found)" % path)
				else:
					raise RuntimeError("'%s' request failed (%d %s)" % (path, response.status, response.reason))

			if cache:
				store = True
				response = response.read()
			else:
				store = False
		else:
			store = False

		return _ParseXML(response, True, store and (lambda obj: cache.store(self._host, path, kw, response, obj)))


#-----------------------------------------------------------------------------
# XML Parser
#-----------------------------------------------------------------------------

def _autocast(s):
	# attempts to cast an XML string to the most probable type.
	try:
		if s.strip("-").isdigit():
			return int(s)
	except ValueError:
		pass

	try:
		return float(s)
	except ValueError:
		pass

	if len(s) == 19 and s[10] == ' ':
		# it could be a date string
		try:
			return max(0, int(timegm(strptime(s, "%Y-%m-%d %H:%M:%S"))))
		except OverflowError:
			pass
		except ValueError:
			pass

	# couldn't cast. return string unchanged.
	return s


class _Parser(object):

	def Parse(self, data, isStream=False):
		self.container = self.root = None
		p = expat.ParserCreate()
		p.StartElementHandler = self.tag_start
		p.CharacterDataHandler = self.tag_cdata
		p.EndElementHandler = self.tag_end
		p.ordered_attributes = True
		p.buffer_text = True

		if isStream:
			p.ParseFile(data)
		else:
			p.Parse(data, True)
		return self.root
		

	def tag_start(self, name, attributes):
		# <hack>
		# If there's a colon in the tag name, cut off the name from the colon
		# onward. This is a workaround to make certain bugged XML responses
		# (such as eve/CharacterID.xml.aspx) work.
		if ":" in name:
			name = name[:name.index(":")]
		# </hack>

		if name == "rowset":
			# for rowsets, use the given name
			try:
				columns = attributes[attributes.index('columns')+1].split(",")
			except ValueError:
				# rowset did not have columns tag set (this is a bug in API)
				# columns will be extracted from first row instead.
				columns = []

			try:
				priKey = attributes[attributes.index('key')+1]
				this = IndexRowset(cols=columns, key=priKey)
			except ValueError:
				this = Rowset(cols=columns)


			this._name = attributes[attributes.index('name')+1]
			this.__catch = "row" # tag to auto-add to rowset.
		else:
			this = Element()
			this._name = name

		this.__parent = self.container

		if self.root is None:
			# We're at the root. The first tag has to be "eveapi" or we can't
			# really assume the rest of the xml is going to be what we expect.
			if name != "eveapi":
				raise RuntimeError("Invalid API response")
			self.root = this

		if isinstance(self.container, Rowset) and (self.container.__catch == this._name):
			# check for missing columns attribute (see above)
			if not self.container._cols:
				self.container._cols = attributes[0::2]

			self.container.append([_autocast(attributes[i]) for i in range(1, len(attributes), 2)])
			this._isrow = True
			this._attributes = None
		else:
			this._isrow = False
			this._attributes = attributes

		self.container = this


	def tag_cdata(self, data):
		if data == "\r\n" or data.strip() != data:
			return

		this = self.container
		data = _autocast(data)

		if this._attributes:
			# this tag has attributes, so we can't simply assign the cdata
			# as an attribute to the parent tag, as we'll lose the current
			# tag's attributes then. instead, we'll assign the data as
			# attribute of this tag.
			this.data = data
		else:
			# this was a simple <tag>data</tag> without attributes.
			# we won't be doing anything with this actual tag so we can just
			# bind it to its parent (done by __tag_end)
			setattr(this.__parent, this._name, data)


	def tag_end(self, name):
		this = self.container
		if this is self.root:
			del this._attributes
			#this.__dict__.pop("_attributes", None)
			return

		# we're done with current tag, so we can pop it off. This means that
		# self.container will now point to the container of element 'this'.
		self.container = this.__parent
		del this.__parent

		attributes = this.__dict__.pop("_attributes")
		if attributes is None:
			# already processed this tag's closure early, in tag_start()
			return

		if self.container._isrow:
			# Special case here. tags inside a row! Such tags have to be
			# added as attributes of the row.
			parent = self.container.__parent

			# get the row line for this element from its parent rowset
			_row = parent._rows[-1]

			# add this tag's value to the end of the row
			_row.append(getattr(self.container, this._name, this))

			# fix columns if neccessary.
			if len(parent._cols) < len(_row):
				parent._cols.append(this._name)
		else:
			# see if there's already an attribute with this name (this shouldn't
			# really happen, but it doesn't hurt to handle this case!
			sibling = getattr(self.container, this._name, None)
			if sibling is None:
				setattr(self.container, this._name, this)
			# Note: there aren't supposed to be any NON-rowset tags containing
			# multiples of some tag or attribute. Code below handles this case.
			elif isinstance(sibling, Rowset):
				# its doppelganger is a rowset, append this as a row to that.
				sibling.append([_autocast(attributes[i]) for i in range(1, len(attributes), 2)])
			elif isinstance(sibling, Element):
				# parent attribute is an element. This means we're dealing
				# with multiple of the same sub-tag. Change the attribute
				# into a Rowset, adding the sibling element and this one.
				rs = Rowset()
				rs.__catch = rs._name = this._name
				rs.append([_autocast(attributes[i]) for i in range(1, len(attributes), 2)])
				rs.append([getattr(sibling, attributes[i]) for i in range(0, len(attributes), 2)])
				setattr(self.container, this._name, rs)
			else:
				# something else must have set this attribute already.
				# (typically the <tag>data</tag> case in tag_data())
				pass

		# Now fix up the attributes and be done with it.
		for i in range(1, len(attributes), 2):
			this.__dict__[attributes[i-1]] = _autocast(attributes[i])

		return




#-----------------------------------------------------------------------------
# XML Data Containers
#-----------------------------------------------------------------------------
# The following classes are the various container types the XML data is
# unpacked into.
#
# Note that objects returned by API calls are to be treated as read-only. This
# is not enforced, but you have been warned.
#-----------------------------------------------------------------------------

class Element(object):
	# Element is a namespace for attributes and nested tags
	def __str__(self):
		return "<Element '%s'>" % self._name


class Row(object):
	# A Row is a single database record associated with a Rowset.
	# The fields in the record are accessed as attributes by their respective
	# column name.
	#
	# To conserve resources, Row objects are only created on-demand. This is
	# typically done by Rowsets (e.g. when iterating over the rowset).
	
	def __init__(self, cols=None, row=None):
		self._cols = cols or []
		self._row = row or []

	def __nonzero__(self):
		return True

	def __ne__(self, other):
		return self.__cmp__(other)

	def __eq__(self, other):
		return self.__cmp__(other) == 0

	def __cmp__(self, other):
		if type(other) != type(self):
			raise TypeError("Incompatible comparison type")
		return cmp(self._cols, other._cols) or cmp(self._row, other._row)

	def __getattr__(self, this):
		try:
			return self._row[self._cols.index(this)]
		except:
			raise AttributeError, this

	def __getitem__(self, this):
		return self._row[self._cols.index(this)]

	def __str__(self):
		return "Row(" + ','.join(map(lambda k, v: "%s:%s" % (str(k), str(v)), self._cols, self._row)) + ")"


class Rowset(object):
	# Rowsets are collections of Row objects.
	#
	# Rowsets support most of the list interface:
	#   iteration, indexing and slicing
	#
	# As well as the following methods: 
	#
	#   IndexedBy(column)
	#     Returns an IndexRowset keyed on given column. Requires the column to
	#     be usable as primary key.
	#
	#   GroupedBy(column)
	#     Returns a FilterRowset keyed on given column. FilterRowset objects
	#     can be accessed like dicts. See FilterRowset class below.
	#
	#   SortBy(column, reverse=True)
	#     Sorts rowset in-place on given column. for a descending sort,
	#     specify reversed=True.
	#
	#   SortedBy(column, reverse=True)
	#     Same as SortBy, except this retuens a new rowset object instead of
	#     sorting in-place.
	#
	#   Select(columns, row=False)
	#     Yields a column values tuple (value, ...) for each row in the rowset.
	#     If only one column is requested, then just the column value is
	#     provided instead of the values tuple.
	#     When row=True, each result will be decorated with the entire row.
	#

	def IndexedBy(self, column):
		return IndexRowset(self._cols, self._rows, column)

	def GroupedBy(self, column):
		return FilterRowset(self._cols, self._rows, column)

	def SortBy(self, column, reverse=False):
		ix = self._cols.index(column)
		self.sort(key=lambda e: e[ix], reverse=reverse)

	def SortedBy(self, column, reverse=False):
		rs = self[:]
		rs.SortBy(column, reverse)
		return rs

	def Select(self, *columns, **options):
		if len(columns) == 1:
			i = self._cols.index(columns[0])
			if options.get("row", False):
				for line in self._rows:
					yield (line, line[i])
			else:
				for line in self._rows:
					yield line[i]
		else:
			i = map(self._cols.index, columns)
			if options.get("row", False):
				for line in self._rows:
					yield line, [line[x] for x in i]
			else:
				for line in self._rows:
					yield [line[x] for x in i]


	# -------------

	def __init__(self, cols=None, rows=None):
		self._cols = cols or []
		self._rows = rows or []

	def append(self, row):
		if isinstance(row, list):
			self._rows.append(row)
		elif isinstance(row, Row) and len(row._cols) == len(self._cols):
			self._rows.append(row._row)
		else:
			raise TypeError("incompatible row type")

	def __add__(self, other):
		if isinstance(other, Rowset):
			if len(other._cols) == len(self._cols):
				self._rows += other._rows
		raise TypeError("rowset instance expected")

	def __nonzero__(self):
		return not not self._rows

	def __len__(self):
		return len(self._rows)

	def copy(self):
		return self[:]

	def __getitem__(self, ix):
		if type(ix) is slice:
			return Rowset(self._cols, self._rows[ix])
		return Row(self._cols, self._rows[ix])

	def sort(self, *args, **kw):
		self._rows.sort(*args, **kw)

	def __str__(self):
		return ("Rowset(columns=[%s], rows=%d)" % (','.join(self._cols), len(self)))

	def __getstate__(self):
		return (self._cols, self._rows)

	def __setstate__(self, state):
		self._cols, self._rows = state



class IndexRowset(Rowset):
	# An IndexRowset is a Rowset that keeps an index on a column.
	#
	# The interface is the same as Rowset, but provides an additional method:
	#
	#   Get(key [, default])
	#     Returns the Row mapped to provided key in the index. If there is no
	#     such key in the index, KeyError is raised unless a default value was
	#     specified.
	#

	def Get(self, key, *default):
		row = self._items.get(key, None)
		if row is None:
			if default:
				return default[0]
			raise KeyError, key
		return Row(self._cols, row)

	# -------------

	def __init__(self, cols=None, rows=None, key=None):
		try:
			self._ki = ki = cols.index(key)
		except IndexError:
			raise ValueError("Rowset has no column %s" % key)

		Rowset.__init__(self, cols, rows)
		self._key = key
		self._items = dict((row[ki], row) for row in self._rows)

	def __getitem__(self, ix):
		if type(ix) is slice:
			return IndexRowset(self._cols, self._rows[ix], self._key)
		return Rowset.__getitem__(self, ix)

	def append(self, row):
		Rowset.append(self, row)
		self._items[row[self._ki]] = row

	def __getstate__(self):
		return (Rowset.__getstate__(self), self._items, self._ki)

	def __setstate__(self, state):
		state, self._items, self._ki = state
		Rowset.__setstate__(self, state)


class FilterRowset(object):
	# A FilterRowset works much like an IndexRowset, with the following
	# differences:
	# - FilterRowsets are accessed much like dicts
	# - Each key maps to a Rowset, containing only the rows where the value
	#   of the column this FilterRowset was made on matches the key.

	def __init__(self, cols=None, rows=None, key=None, key2=None, dict=None):
		if dict is not None:
			self._items = items = dict
		elif cols is not None:
			self._items = items = {}

			idfield = cols.index(key)
			if not key2:
				for row in rows:
					id = row[idfield]
					if id in items:
						items[id].append(row)
					else:
						items[id] = [row]
			else:
				idfield2 = cols.index(key2)
				for row in rows:
					id = row[idfield]
					if id in items:
						items[id][row[idfield2]] = row
					else:
						items[id] = {row[idfield2]:row}

		self._cols = cols
		self.key = key
		self.key2 = key2
		self._bind()

	def _bind(self):
		items = self._items
		self.keys = items.keys
		self.iterkeys = items.iterkeys
		self.__contains__ = items.__contains__
		self.has_key = items.has_key
		self.__len__ = items.__len__
		self.__iter__ = items.__iter__

	def copy(self):
		return FilterRowset(self._cols[:], None, self.key, self.key2, dict=copy.deepcopy(self._items))

	def get(self, key, default=_unspecified):
		try:
			return self[key]
		except KeyError:
			if default is _unspecified:
				raise
		return default

	def __getitem__(self, i):
		if self.key2:
			return IndexRowset(self._cols, None, self.key2, self._items.get(i, {}))
		return Rowset(self._cols, self._items[i])

	def __getstate__(self):
		return (self._cols, self._rows, self._items, self.key, self.key2)

	def __setstate__(self, state):
		self._cols, self._rows, self._items, self.key, self.key2 = state
		self._bind()

